wrappers.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import threading
  2. from contextlib import contextmanager
  3. import os
  4. from os.path import dirname, abspath, join as pjoin
  5. import shutil
  6. from subprocess import check_call, check_output, STDOUT
  7. import sys
  8. from tempfile import mkdtemp
  9. from . import compat
  10. try:
  11. import importlib.resources as resources
  12. def _in_proc_script_path():
  13. return resources.path(__package__, '_in_process.py')
  14. except ImportError:
  15. @contextmanager
  16. def _in_proc_script_path():
  17. yield pjoin(dirname(abspath(__file__)), '_in_process.py')
  18. @contextmanager
  19. def tempdir():
  20. td = mkdtemp()
  21. try:
  22. yield td
  23. finally:
  24. shutil.rmtree(td)
  25. class BackendUnavailable(Exception):
  26. """Will be raised if the backend cannot be imported in the hook process."""
  27. def __init__(self, traceback):
  28. self.traceback = traceback
  29. class BackendInvalid(Exception):
  30. """Will be raised if the backend is invalid."""
  31. def __init__(self, backend_name, backend_path, message):
  32. self.backend_name = backend_name
  33. self.backend_path = backend_path
  34. self.message = message
  35. class HookMissing(Exception):
  36. """Will be raised on missing hooks."""
  37. def __init__(self, hook_name):
  38. super(HookMissing, self).__init__(hook_name)
  39. self.hook_name = hook_name
  40. class UnsupportedOperation(Exception):
  41. """May be raised by build_sdist if the backend indicates that it can't."""
  42. def __init__(self, traceback):
  43. self.traceback = traceback
  44. def default_subprocess_runner(cmd, cwd=None, extra_environ=None):
  45. """The default method of calling the wrapper subprocess."""
  46. env = os.environ.copy()
  47. if extra_environ:
  48. env.update(extra_environ)
  49. check_call(cmd, cwd=cwd, env=env)
  50. def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None):
  51. """A method of calling the wrapper subprocess while suppressing output."""
  52. env = os.environ.copy()
  53. if extra_environ:
  54. env.update(extra_environ)
  55. check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)
  56. def norm_and_check(source_tree, requested):
  57. """Normalise and check a backend path.
  58. Ensure that the requested backend path is specified as a relative path,
  59. and resolves to a location under the given source tree.
  60. Return an absolute version of the requested path.
  61. """
  62. if os.path.isabs(requested):
  63. raise ValueError("paths must be relative")
  64. abs_source = os.path.abspath(source_tree)
  65. abs_requested = os.path.normpath(os.path.join(abs_source, requested))
  66. # We have to use commonprefix for Python 2.7 compatibility. So we
  67. # normalise case to avoid problems because commonprefix is a character
  68. # based comparison :-(
  69. norm_source = os.path.normcase(abs_source)
  70. norm_requested = os.path.normcase(abs_requested)
  71. if os.path.commonprefix([norm_source, norm_requested]) != norm_source:
  72. raise ValueError("paths must be inside source tree")
  73. return abs_requested
  74. class Pep517HookCaller(object):
  75. """A wrapper around a source directory to be built with a PEP 517 backend.
  76. source_dir : The path to the source directory, containing pyproject.toml.
  77. build_backend : The build backend spec, as per PEP 517, from
  78. pyproject.toml.
  79. backend_path : The backend path, as per PEP 517, from pyproject.toml.
  80. runner : A callable that invokes the wrapper subprocess.
  81. The 'runner', if provided, must expect the following:
  82. cmd : a list of strings representing the command and arguments to
  83. execute, as would be passed to e.g. 'subprocess.check_call'.
  84. cwd : a string representing the working directory that must be
  85. used for the subprocess. Corresponds to the provided source_dir.
  86. extra_environ : a dict mapping environment variable names to values
  87. which must be set for the subprocess execution.
  88. """
  89. def __init__(
  90. self,
  91. source_dir,
  92. build_backend,
  93. backend_path=None,
  94. runner=None,
  95. ):
  96. if runner is None:
  97. runner = default_subprocess_runner
  98. self.source_dir = abspath(source_dir)
  99. self.build_backend = build_backend
  100. if backend_path:
  101. backend_path = [
  102. norm_and_check(self.source_dir, p) for p in backend_path
  103. ]
  104. self.backend_path = backend_path
  105. self._subprocess_runner = runner
  106. @contextmanager
  107. def subprocess_runner(self, runner):
  108. """A context manager for temporarily overriding the default subprocess
  109. runner.
  110. """
  111. prev = self._subprocess_runner
  112. self._subprocess_runner = runner
  113. try:
  114. yield
  115. finally:
  116. self._subprocess_runner = prev
  117. def get_requires_for_build_wheel(self, config_settings=None):
  118. """Identify packages required for building a wheel
  119. Returns a list of dependency specifications, e.g.:
  120. ["wheel >= 0.25", "setuptools"]
  121. This does not include requirements specified in pyproject.toml.
  122. It returns the result of calling the equivalently named hook in a
  123. subprocess.
  124. """
  125. return self._call_hook('get_requires_for_build_wheel', {
  126. 'config_settings': config_settings
  127. })
  128. def prepare_metadata_for_build_wheel(
  129. self, metadata_directory, config_settings=None,
  130. _allow_fallback=True):
  131. """Prepare a *.dist-info folder with metadata for this project.
  132. Returns the name of the newly created folder.
  133. If the build backend defines a hook with this name, it will be called
  134. in a subprocess. If not, the backend will be asked to build a wheel,
  135. and the dist-info extracted from that (unless _allow_fallback is
  136. False).
  137. """
  138. return self._call_hook('prepare_metadata_for_build_wheel', {
  139. 'metadata_directory': abspath(metadata_directory),
  140. 'config_settings': config_settings,
  141. '_allow_fallback': _allow_fallback,
  142. })
  143. def build_wheel(
  144. self, wheel_directory, config_settings=None,
  145. metadata_directory=None):
  146. """Build a wheel from this project.
  147. Returns the name of the newly created file.
  148. In general, this will call the 'build_wheel' hook in the backend.
  149. However, if that was previously called by
  150. 'prepare_metadata_for_build_wheel', and the same metadata_directory is
  151. used, the previously built wheel will be copied to wheel_directory.
  152. """
  153. if metadata_directory is not None:
  154. metadata_directory = abspath(metadata_directory)
  155. return self._call_hook('build_wheel', {
  156. 'wheel_directory': abspath(wheel_directory),
  157. 'config_settings': config_settings,
  158. 'metadata_directory': metadata_directory,
  159. })
  160. def get_requires_for_build_sdist(self, config_settings=None):
  161. """Identify packages required for building a wheel
  162. Returns a list of dependency specifications, e.g.:
  163. ["setuptools >= 26"]
  164. This does not include requirements specified in pyproject.toml.
  165. It returns the result of calling the equivalently named hook in a
  166. subprocess.
  167. """
  168. return self._call_hook('get_requires_for_build_sdist', {
  169. 'config_settings': config_settings
  170. })
  171. def build_sdist(self, sdist_directory, config_settings=None):
  172. """Build an sdist from this project.
  173. Returns the name of the newly created file.
  174. This calls the 'build_sdist' backend hook in a subprocess.
  175. """
  176. return self._call_hook('build_sdist', {
  177. 'sdist_directory': abspath(sdist_directory),
  178. 'config_settings': config_settings,
  179. })
  180. def _call_hook(self, hook_name, kwargs):
  181. # On Python 2, pytoml returns Unicode values (which is correct) but the
  182. # environment passed to check_call needs to contain string values. We
  183. # convert here by encoding using ASCII (the backend can only contain
  184. # letters, digits and _, . and : characters, and will be used as a
  185. # Python identifier, so non-ASCII content is wrong on Python 2 in
  186. # any case).
  187. # For backend_path, we use sys.getfilesystemencoding.
  188. if sys.version_info[0] == 2:
  189. build_backend = self.build_backend.encode('ASCII')
  190. else:
  191. build_backend = self.build_backend
  192. extra_environ = {'PEP517_BUILD_BACKEND': build_backend}
  193. if self.backend_path:
  194. backend_path = os.pathsep.join(self.backend_path)
  195. if sys.version_info[0] == 2:
  196. backend_path = backend_path.encode(sys.getfilesystemencoding())
  197. extra_environ['PEP517_BACKEND_PATH'] = backend_path
  198. with tempdir() as td:
  199. hook_input = {'kwargs': kwargs}
  200. compat.write_json(hook_input, pjoin(td, 'input.json'),
  201. indent=2)
  202. # Run the hook in a subprocess
  203. with _in_proc_script_path() as script:
  204. self._subprocess_runner(
  205. [sys.executable, str(script), hook_name, td],
  206. cwd=self.source_dir,
  207. extra_environ=extra_environ
  208. )
  209. data = compat.read_json(pjoin(td, 'output.json'))
  210. if data.get('unsupported'):
  211. raise UnsupportedOperation(data.get('traceback', ''))
  212. if data.get('no_backend'):
  213. raise BackendUnavailable(data.get('traceback', ''))
  214. if data.get('backend_invalid'):
  215. raise BackendInvalid(
  216. backend_name=self.build_backend,
  217. backend_path=self.backend_path,
  218. message=data.get('backend_error', '')
  219. )
  220. if data.get('hook_missing'):
  221. raise HookMissing(hook_name)
  222. return data['return_val']
  223. class LoggerWrapper(threading.Thread):
  224. """
  225. Read messages from a pipe and redirect them
  226. to a logger (see python's logging module).
  227. """
  228. def __init__(self, logger, level):
  229. threading.Thread.__init__(self)
  230. self.daemon = True
  231. self.logger = logger
  232. self.level = level
  233. # create the pipe and reader
  234. self.fd_read, self.fd_write = os.pipe()
  235. self.reader = os.fdopen(self.fd_read)
  236. self.start()
  237. def fileno(self):
  238. return self.fd_write
  239. @staticmethod
  240. def remove_newline(msg):
  241. return msg[:-1] if msg.endswith(os.linesep) else msg
  242. def run(self):
  243. for line in self.reader:
  244. self._write(self.remove_newline(line))
  245. def _write(self, message):
  246. self.logger.log(self.level, message)